Mit Module Federation und Web Components Angular und React gemeinsam nutzen
Microfrontends bringen – zumindest theoretisch – die Vorteile von Microservices in den Client: Einzelne autarke Teams entwickeln und deployen ihre Codestrecken unabhängig voneinander und treffen auch ihre eigenen Entscheidungen. Das geht sogar so weit, dass jedes Team über die zu nutzenden Technologien, darunter Bibliotheken und Frameworks sowie deren Versionen, selbst entscheidet. Allerdings führt die Nutzung unterschiedlicher Frameworks und Versionen im Frontend zu Herausforderungen: Zum einen müssen sie in den Browser geladen werden und erhöhen somit die Anzahl an Bytes, die über die Leitung gehen. Zum anderen müssen die verschiedenen Frameworks und Versionen, die eigentlich gar nichts voneinander wissen, zum Zusammenspiel bewegt werden. Es gibt allerdings eine Lösung für dieses Problem, indem man die Module Federation mit Web Components kombiniert. Letztere basieren auf unterschiedlichen Angular-Versionen und auf React. Welche Konsequenzen dieser Ansatz mit sich bringt, beschreibe ich am Ende dieses Artikels. Der Quellcode des dazu verwendeten Beispiels findet sich unter [1].
Das Beispiel zur Ausgangssituation
Um zu veranschaulichen, wie sich mit der Kombination von Module Federation und Web Components verschiedene Frameworks, bzw. verschiedene Framework-Versionen, kombinieren lassen, kommt die in Abbildung 1gezeigte Anwendung zum Einsatz.
Dieser Screenshot lässt schon erkennen, dass die Beispielanwendung sowohl Angular als auch React kombiniert. Tatsächlich kombiniert sie aber ebenfalls noch zwei verschiedene Angular-Versionen (Abb. 2).
Interessant hieran ist auch, dass sich jeweils zwei Anwendungen eine Angular-Version teilen. Dies lässt sich, wie weiter unten beschrieben, mit dem Standardverhalten von Module Federation erreichen. Damit die einzelnen Frameworks und die unterschiedlichen Framework-Versionen zusammenspielen, wurden sie als Web Components veröffentlicht.
LUST AUF MEHR SOFTWARE ARCHITEKTUR?
Zahlreiche aktuelle Themen wie KI, LLMS und Machine Learning, sowie Frontend-Architektur, für Tools zur Softwarearchitektur, Cloudlösungen und Software beweisen.
Web Components mit Module Federation bereitstellen
Das vom Angular-Team entwickelte Projekt @angular/elements erlaubt das Bereitstellen von Angular-Komponenten als Framework-unabhängige Web Components. Da auch eine Angular-Anwendung lediglich eine Komponente ist, die wiederum aus zahlreichen weiteren Komponenten besteht, lassen sich damit auch ganze Anwendungen in Web Components umwandeln. Um @ angular/elements zu nutzen, ist das gleichnamige Paket zu installieren:
ng add @angular/elements
Anschließend können Sie seine Methode createCustomElement nutzen, um eine Angular-Komponente als Web Component zu verpacken. Listing 1 übernimmt diese Aufgabe in der Methode ngDoBootstrap des AppModules.
Listing 1
[...]
import { createCustomElement } from '@angular/elements';
[...]
@NgModule({
[…]
declarations: [ [...], AppComponent ],
bootstrap: [] // No bootstrap components!
})
export class AppModule {
constructor(private injector: Injector) { }
ngDoBootstrap() {
const ce = createCustomElement(AppComponent, {injector: this.injector});
customElements.define('mfe1-element', ce);
}
}
Angular ruft ngDoBootstrap auf, wenn das AppModule keine Bootstrap-Komponente aufweist. In diesem Fall sind die Autor:innen selbst für die Bootstrapping-Logik verantwortlich. Neben der Angular-Komponente bekommt createCustomElement auch den aktuellen Injector übergeben, um die Komponente mit dem Dependency-Injection-Mechanismus von Angular zu verbinden. Die Methode customElements.define wird durch den zugrunde liegenden Browserstandard definiert und registriert die Web Component unter dem Elementnamen mfe1-element. Insofern lässt sie sich nun mit jedem beliebigen Framework mittels <mfe1-element></mfe1-element> aufrufen. Inputs und Outputs bildet Angular Elements übrigens auf gleichnamige Eigenschaften und Events ab.
Um diese Web Component nun der Shell über Module Federation anzubieten, müssen wir das Paket @angular-architects/module-federation [2] installieren:
ng add @angular-architects/module-federation
Dieser Aufruf generiert unter anderem eine webpack.config.js mit der Konfiguration für Module Federation. Mit dieser Konfiguration wird uns das Veröffentlichen von Dateien für die Shell erlaubt. Listing 2 stellt zum Beispiel unter dem relativen Pfad ./web-components den Inhalt der Datei ./src/bootstrap.ts bereit.
Listing 2
new ModuleFederationPlugin({
name: "mfe1",
filename: "remoteEntry.js",
exposes: {
'./web-components': './src/bootstrap.ts',
},
shared: ["@angular/core", "@angular/common", "@angular/router"]
})
Die Datei bootstrap.ts wurde vom Paket @angular-architects/module-federation generiert. Es kümmert sich um das Bootstrapping des AppModule. Da wir im AppModule auch die Web Component veröffentlichen, bekommt die Shell darauf Zugriff. Normalerweise platziert das CLI den Quellcode für Boostrapping in der Datei main.ts. Aus technischen Gründen verschiebt @ angular-architects/module-federation ihren Inhalt jedoch nach bootstrap.ts.
Außerdem definiert die gezeigte Konfiguration den Namen des Microfrontends (name) sowie den Namen des von Module Federation generierten Remote Entry Points mit Metadaten zum Microfrontend (filename). Diese Datei gilt es in die Shell zu laden, sodass sie alle nötigen Informationen zur Nutzung des jeweiligen Microfrontends hat.
Unter shared finden sich jene npm-Pakete, die es mit der Shell und anderen Microfrontends zu teilen gilt. Bevor Module Federation eine Abhängigkeit teilt, prüft es, ob die angebotenen und benötigten Versionen kompatibel zueinander sind. Dazu verwendet es die Einträge aus der Datei package.json. Sind sie nicht zueinander kompatibel, lädt Module Federation standardmäßig beide Versionen. Durch dieses Standardverhalten ergibt sich die in Abbildung 2 dargestellte Situation.
React und Web Components
Im Gegensatz zu Angular bietet React leider keine offizielle Unterstützung für Web Components. Allerdings finden sich Communityprojekte, die sich dieser Aufgabe angenommen haben. Als Alternative können Sie auch manuell eine native Web Component erstellen und darin eine React-Komponente rendern (Listing 3).
Listing 3
// Native Web Component extending Browser's HTMLElement
class Mfe4Element extends HTMLElement {
connectedCallback() {
ReactDOM.render(<App/>, this);
}
}
customElements.define('mfe4-element', Mfe4Element);
Diese Web Component lässt sich mit Module Federation veröffentlichen. Im Fall von React können Sie jedoch die webpack.config.js direkt um die gezeigten Einträge erweitern. Ein Paket für den Brückenschlag wie @angular-architects/module-federation ist hier also nicht notwendig.
Web Components mit Module Federation laden
Da im hier präsentierten Beispiel auch die Shell auf Angular basiert, bekommt sie das Paket @angular-architects/module-federation installiert. In ihrer webpack.config.js sind nun die einzelnen Microfrontends als remotes zu registrieren (Listing 4).
Listing 4
[...]
new ModuleFederationPlugin({
remotes: {
'mfe1': "mfe1@http://localhost:4201/remoteEntry.js",
[...]
},
shared: ["@angular/core", "@angular/common", "@angular/router"]
})
[...]
Diese Einträge bilden Pfade (hier mfe1) auf die Namen der veröffentlichten Microfrontends (hier ebenfalls mfe1) und deren Remote Entry Points (hier http://localhost:4201/remoteEntry.js) ab. Außerdem sind auch hier die zu teilenden Bibliotheken zu hinterlegen. Anschließend kann die Shell mit einem dynamischen import die Microfrontends laden:
await import('mfe1/web-components');
Die davon registrierten Web Components lassen sich danach genauso wie andere HTML-Elemente behandeln und dynamisch in die Seite einfügen:
const element = document.createElement('mfe1-element');
document.body.appendChild(element);
Um mit dem Low-Level-Code für das Laden und Instanziieren von Web Components nicht ständig konfrontiert zu werden, versteckt das hier besprochene Beispiel ihn in einer Wrapper-Komponente. Dabei handelt es sich um eine Angular-Komponente, die die nötigen Eckdaten über die Routenkonfiguration erhält (Listing 5).
Listing 5
RouterModule.forRoot([
{ [...], component: WrapperComponent,
data: { importName: 'mfe1', elementName: 'mfe1-element' }},
{ [...], component: WrapperComponent,
data: { importName: 'mfe2', elementName: 'mfe2-element' }},
[...]
])
Dieser Wrapper ist auch notwendig, weil der Angular-Router nur Angular-Komponenten und keine Web Components aktivieren kann.
Dynamic Federation
Falls Sie das Registrieren der Microfrontends in der webpack.config.js zu sehr einschränkt, können Sie auch auf Dynamic Federation [3] setzen. In diesem Fall können Sie auf den Abschnitt remote verzichten und geben stattdessen die Eckdaten für das Laden des Microfrontends zur Laufzeit bekannt. Das Paket @angular-architects/module-federation bietet dafür die Methode loadRemoteModule an:
await loadRemoteModule({
remoteEntry: 'http://localhost:4201/remoteEntry.js',
remoteName: 'mfe1',
exposedModule: './web-components
})
Bewertung
Die bisher beschriebene Lösung erfüllt die eingangs festgelegten Anforderungen. Sie kommt jedoch nicht ohne Nachteile. Deswegen möchte ich den restlichen Artikel zur Bewertung der Vor- und Nachteile dieses Ansatzes nutzen.
Software Architecture Summit vom 11. - 13. März in München
Technische und methodische Themen, Kommunikationstrends, Cloudlösungen, MLOps, Design und Psychologie
Laden von Microfrontends
Das Laden von Microfrontends gestaltet sich sehr einfach. Dank Dynamic Federation kann sich die Shell zur Laufzeit über verfügbare Microfrontends informieren und sie bei Bedarf laden. Das Schöne dabei ist, dass Angular hiervon gar nichts mitbekommt. Aus der Sicht unseres führenden Frameworks findet hier lediglich Lazy Loading statt. Wir können es also prinzipiell so nutzen, wie es gedacht ist, und kommen ohne komplizierte zusätzliche Meta-Frameworks aus.
Teilen von Abhängigkeiten und Bundle Size
Das Teilen von Abhängigkeiten zwischen separat kompilierten Microfrontends ist wohl eines der mächtigsten Möglichkeiten von Module Federation. Sofern zwei oder mehr Microfrontends eine kompatible Version derselben Abhängigkeit verwenden, lädt Module Federation diese nur einmal. Damit Module Federation weiß, welches Microfrontend welche Abhängigkeiten und Versionen benötigt, sind vorab deren Remote Entry Points in die Shell zu laden. Die zu teilenden Abhängigkeiten können jedoch nicht mit Tree Shaking optimiert werden. Diese Technik bedeutet ja, dass der Build-Prozess sämtliche nicht benötigten Bestandteile einer Bibliothek entfernt. Allerdings weiß der Build-Prozess nicht, wie andere erst zur Laufzeit geladene Microfrontends eine geteilte Abhängigkeit nutzen. Deswegen deaktiviert Module Federation für sämtliche geteilte Abhängigkeiten das Tree Shaking. Dazu kommt, dass gerade in unserem Szenario auch mehrere Frameworks und Versionen geladen werden müssen. Das erhöht zusätzlich die Anzahl an Bytes, die über die Leitung wandern müssen. Inwieweit das kritisch ist, gilt es von Fall zu Fall abzuschätzen. Während die Bundle Size bei öffentlichen Webauftritten maßgeblich den Erfolg beeinflussen kann, ist sie bei internen Anwendungen häufig weniger wichtig.
Web Components
Dank Web Components lassen sich die einzelnen Frameworks und Versionen voreinander verbergen. Web Components funktionieren auch mittlerweile in allen modernen Browsern. Wer noch Internet Explorer 11 unterstützen muss, kann sich mit Polyfills behelfen. Die sind zwar nicht perfekt, man kann sich damit jedoch arrangieren. Da wir Web Components aber nicht direkt mit dem Router nutzen können, benötigen wir eine generische Wrapper-Komponente. Diese verbirgt auch die nicht so schönen technischen Details des dynamischen Erzeugens von Web Components.
Zusammenspiel verschiedener Router
Router gehen in der Regel davon aus, dass sie allein über die Seite bestimmen können. Da wir nun mehrere Single Page Applications in den Browser laden, müssen wir deren Router zum Zusammenspiel bewegen. Dazu sind ein paar Workarounds notwendig. Beispielsweise müssen wir nun die Router wissen lassen, welcher Teil des URLs für sie bestimmt ist. Nehmen wir als Beispiel den URL /mfe1/a. Das erste Segment, mfe1, signalisiert dem Router der Shell, dass das Microfrontend 1 zu laden ist. Das zweite Segment, /a, teilt hingegen dem Router von Microfrontend 1 mit, welche Route er aktivieren soll. Die hier verwendete Lösung, die sich unter [1] findet, verwendet statt Pfaden sogenannte UrlMatcher. Dabei handelt es sich um Funktionen, die dem Router sagen, ob eine bestimmte Route zu aktivieren ist. Der Matcher startsWith legt beispielsweise fest, dass eine Route aktiviert wird, wenn der URL mit einem bestimmten Segment beginnt:
{ matcher: startsWith('mfe1'), component: WrapperComponent, data: { importName: 'mfe1', elementName: 'mfe1-element' }}
Alternativ dazu bestimmt ein endsWith(‚a‘), dass eine Route dann zu aktivieren ist, wenn der URL mit dem Segment /a endet.
Abgesehen davon kommt es vor, dass sich ein Angular-Router in einer verzögert geladenen Web Component gar nicht für den aktuellen URL zuständig fühlt, zumal Router in der Regel beim Programmstart geladen werden. In diesem Fall benötigt dieser Router eine Sondereinladung. Diese ist bei jeder URL-Änderung auszustellen. Hierzu kann die Anwendung auf das popstate Event horchen und das Routing mit der Methode navigateByUrl des Angular-Routers anfordern. Wie bei allen Workarounds empfiehlt es sich, diese hinter ein paar Hilfskonstrukten zu verstecken, damit Entwickler:innen nicht ständig damit konfrontiert sind.
Workarounds für Angular und Zone.js
Gerade Angular bringt noch zwei weitere Herausforderungen mit sich. Eine besteht in der Tatsache, dass Angular-Anwendungen jeweils für eine Plattform gestartet werden. Die wohl häufigste Plattform führt Angular im Browser aus. Für Server-side Rendering existiert eine weitere Plattform. Leider darf jede Plattform nur einmal instanziiert werden. Wenn jedoch eine Angular-Version von mehreren Microfrontends geteilt wird, würden diese mit dem standardmäßig vom CLI generierten Code mehrere Instanzen davon erzeugen. Ein ähnliches Problem ergibt sich beim Einsatz von Zone.js. Diese Abhängigkeit von Angular sollte auch nur ein einziges Mal instanziiert werden, um Probleme bei der Change Detection zu vermeiden.
Die Lösung ist für beide Fälle dieselbe: Wir müssen die erzeugten Plattform- sowie Zone.js-Instanzen anderen Microfrontends über den globalen Namensraum zur Verfügung stellen. Den hierzu notwendigen Work-around implementiert die hier diskutierte Lösung [1] in den Dateien bootstrap.ts und app.component.ts.
Fazit
Module Federations erlauben das Laden separat bereitgestellter Microfrontends zur Laufzeit. Es erlaubt auch das Teilen von Abhängigkeiten und bringt Strategien zum Umgang mit Konflikten mit sich. Das alles gestaltet sich für uns Entwickler:innen äußerst geradlinig. Aus Sicht der Frameworks wie Angular oder React gestaltet sich alles wie normales Lazy Loading. Das macht den Einsatz komplexer zusätzlicher Meta-Frameworks unnötig.
Durch das Hinzuziehen von Web Components lassen sich auch unterschiedliche Frameworks und Versionen voreinander verbergen. Das erhöht natürlich die Bundle Size und kann sich somit auf die (Start-)Performance der Anwendung auswirken. Außerdem macht dieses Vorgehen einige Workarounds notwendig. Das erhöht wiederum die Komplexität der Lösung.
Eine Selbstbeschränkung auf eine Major-Version des führenden Frameworks – sofern das möglich ist – sollte Vorteile bringen. Ist das aber im gegebenen Fall zu restriktiv, können sich Teams mit den hier aufgezeigten Maßnahmen mehr Freiheiten „erkaufen“. Wie immer im Bereich der Softwarearchitektur gilt es somit auch hier, die vorherrschenden Architekturanforderungen zu kennen und die möglichen Ansätze vor deren Hintergrund zu bewerten.